跳到主要内容

Go 的文件系统 fs 包

fs 包定义了文件系统的基本接口。

Go 语言从 1.16 开始增加了 io/fs 包,该包定义了一个文件系统需要的相关基础接口,因此称之为抽象文件系统。该文件系统是层级文件系统或叫树形文件系统,Unix 文件系统就是这种类型。

注意:注意这个 fs.File 不是我们熟悉的那个 *os.File,而是一个接口,这接口实际上是 io.Readerio.Closer 的组合。所以这个接口是只读的,无法写

ioutil 包的变化

在 1.16 之后 ioutil 这个包被废弃了(看以下注释)

// As of Go 1.16, the same functionality is now provided
// by package io or package os, and those implementations
// should be preferred in new code.
// See the specific function documentation for details.
package ioutil

所以在 1.16 对应的函数在 io 和 os 中

在 1.16 之前的用法:

// findExtFile 查找dir目录下的所有文件,返回第一个文件名以ext为扩展名的文件内容
//
// 假设一定存在至少一个这样的文件
func findExtFile(dir string, ext string) ([]byte, error) {
entries, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
}

for _, e := range entries {
if filepath.Ext(e.Name()) == ext && !e.IsDir() {
name := filepath.Join(dir, e.Name())
// return ioutil.ReadFile(name)
f, err := os.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return ioutil.ReadAll(f)
}
}

panic("never happen")
}

可以看到都是操作 ioutil 这个工具包来读取目录或者文件夹的

在 1.16 之后的用法:

func findExtFileGo116(dir string, ext string) ([]byte, error) {
fsys := os.DirFS(dir) // 以dir为根目录的文件系统,也就是说,后续所有的文件在这目录下
entries, err := fs.ReadDir(fsys, ".") // 读当前目录
if err != nil {
return nil, err
}
for _, e := range entries {
if filepath.Ext(e.Name()) == ext && !e.IsDir() {
// 也可以一行代码返回
// return fs.ReadFile(fsys, e.Name())
f, err := fsys.Open(e.Name()) // 文件名fullname `{dir}/{e.Name()}``
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
}
panic("never happen")
}

可以发现不再使用 ioutil 这个工具包,而是变成 fs 这个抽象的文件接口,且一些 ioutil 包里面的内容也被放到 io 包里面

之所以需要抽象出一个文件系统是为了方便按照需要替换成不同的文件系统实现,比如内存/磁盘/网络/分布式的文件系统,这比在代码里硬编码 os.Open 要更灵活,如果代码中全是 os 包,则将实现完全绑定在了操作系统的文件系统,一个最直接的问题是单元测试比较难做

下面介绍下使用 fs 包的接口如何做单元测试

单元测试时进行模拟

而前面说了这个包最重要的意义是抽象了文件接口,所以像需要单元测试时就可以使用这个接口来模拟本地读取文件

首先创建一个被测的工具类包

// finder.go
type Finder struct {
fs fs.ReadFileFS
}

// fileContains666 reports whether name contains `666` text.
func (f *Finder) fileContains666(name string) (bool, error) {
b, err := f.fs.ReadFile(name)
return bytes.Contains(b, []byte("666")), err
}

这个工具类用来判断当前读取的文件内是否存在 666 这个字符串,可以看内部并没有绑定某个文件,而是通过 ReadFileFS 接口的,所以这时就可以使用 Go1.16 提供了一个 fstest.MapFS 的 fs 的实现:

// A MapFS is a simple in-memory file system for use in tests,
// represented as a map from path names (arguments to Open)
// to information about the files or directories they represent.
//
// deleted comment...
type MapFS map[string]*MapFile

// A MapFile describes a single file in a MapFS.
type MapFile struct {
Data []byte // file content
Mode fs.FileMode // FileInfo.Mode
ModTime time.Time // FileInfo.ModTime
Sys interface{} // FileInfo.Sys
}

var _ fs.FS = MapFS(nil)
var _ fs.File = (*openMapFile)(nil)

使用例,编写一个测试文件

// finder_test.go
var testFs = fstest.MapFS{
"666.txt": {
Data: []byte("hello, 666"),
Mode: 0456,
ModTime: time.Now(),
Sys: 1,
},
"no666.txt": {
Data: []byte("hello, world"),
Mode: 0456,
ModTime: time.Now(),
Sys: 1,
},
}

func TestFileContains666(t *testing.T) {
finder := &Finder{fs: testFs}
if got, err := finder.fileContains666("666.txt"); !got || err != nil {
t.Fatalf("fileContains666(%q) = %t, %v, want true, nil", "666.txt", got, err)
}

if got, err := finder.fileContains666("no666.txt"); got || err != nil {
t.Fatalf("fileContains666(%q) = %t, %v, want false, nil", "no666.txt", got, err)
}
}

如上,简单的 Mock 了这个文件系统

References

Go 1.16 io/fs 设计与实现及正确使用姿势